篇首语:本文由编程笔记#小编为大家整理,主要介绍了SSO 轻量级实现指南(原生 Java 实现):SSO Server 部分相关的知识,希望对你有一定的参考价值。
OAuth 是当前单点登录(SSO)和用户授权的标准协议——现在就让我们一起动手撸一个 SSO 的实现吧!
源码在:
- SSO 中心 https://gitee.com/sp42_admin/ajaxjs/tree/master/aj-sso
- SSO Client 客户端 https://gitee.com/sp42_admin/ajaxjs/tree/master/aj-sso-client
我们开源的特色:
SSO Server 即 SSO 中心,负责统一用户认证的。另外有 SSO Client 部分,我们另起文章再讲。
开始之前先说说废话(之所以说废话的原因是,其实你可以无视这段概念性的介绍,直接开撸)。
OAuth 是 OAuth,OAuth 不单单为 SSO 服务的。OAuth 协议初衷是为了用户不用告诉第三方系统账号和密码就可以访问受限的资源,——可以成为 SSO 的通行协议这个想必原设计者都没有料到的。没有 OAuth 之前,SSO 老早就有,只是各家各法自行实现,总能达到单点登录的目的。也就是说,SSO 的协议不一定是 OAuth,而 OAuth 不一定服务于 SSO。
SSO 与 OAuth 都是紧扣“我是谁”之要义,即用户身份认证的问题,这也是核心的问题,所有关于用户一切的信息都应由 SSO 中心或 OAuth 资源服务器所把控。稍有出入的是 OAuth 认证服务器往往是与资源服务器在一起的,这个一起的意思可以是物理意义上的同一部机器。但 SSO 中心呢?一般简单、纯粹的得多,就是做用户认证的,——即使涉及用户权限的 SSO 中心,顶多也是功能性的、系统级的权限控制,而不是垂直的数据权限控制(资源的权限控制)。也就是说,SSO 中心不负责资源问题,而资源往往在客户端 Client 那边。总之,狭义的 OAuth 很可能是整个大系统中,对外服务的一个模块;而 SSO 中心则纯粹得多,通常独立部署,独立服务,只做好 SSO 一件事情。
在流程上,SSO 与 OAuth 也有显著不同,例如同意授权访问,典型的第三方登录是有这一步的(如下图所示),但 SSO 没有吧?
SSO 流程如下(借图,来自这里)
当前是使用账号密码登录,未来也应该支持如微信、微博的第三方登录。登录的作于在于识别“我是谁”的目的,在 SSO 中心标识某某用户已经是在登录的状态,以实现“单点登录”。具体说,会产生关键的标识状态 Session 和浏览器 COOKIEs。Session 仍是记忆登录状态的重要信息,否则后面获取 Token 就无法进行(因为不知道哪个用户!)。
登录控制器 LoginController
源码在这里,关键的 Service 部分在这里。
登录成功或失败一般允许指 redirectUrl,但我们没有,因为当前这登录接口是跨域的,界面完全由客户端指定,所以客户端自己控制就好。不过感觉上登录界面放在 SSO 中心会安全一点吧,毕竟允许跨域了。
当前注销只是清空 session 而已,但实际 SSO 复杂得多,理论上某个应用注销了,其他所有已登录的应用也有要同步注销。这部分暂且不表,待后面补充。
注册分为用户注册和客户端注册。
用户注册没什么好说的,常规流程的逻辑,参见源码。
接入的客户端有时也称“应用”。客户端模型如下面 SQL 所示。clientId
有时也称 appId
或 appKey
。clientSecret
是密钥,但跟密码的意思没什么区别,肯定不能外泄出去。
CREATE TABLE `auth_client_details` (
`id` INT(11) NOT NULL AUTO_INCREMENT COMMENT '主键 id,自增',
`name` VARCHAR(20) NOT NULL COMMENT '客户端名称' COLLATE 'utf8mb4_bin',
`content` VARCHAR(256) NULL DEFAULT NULL COMMENT '简介' COLLATE 'utf8mb4_bin',
`clientId` VARCHAR(100) NOT NULL COMMENT '接入的客户端ID' COLLATE 'utf8mb4_bin',
`clientSecret` VARCHAR(255) NOT NULL COMMENT '接入的客户端的密钥' COLLATE 'utf8mb4_bin',
`redirecUri` VARCHAR(1000) NULL DEFAULT NULL COMMENT '回调地址' COLLATE 'utf8mb4_bin',
`stat` TINYINT(4) NULL DEFAULT NULL COMMENT '数据字典:状态',
`uid` BIGINT(20) NULL DEFAULT NULL COMMENT '唯一 id,通过 uuid 生成不重复 id',
`extend` TEXT NULL DEFAULT NULL COMMENT '扩展 JSON 字段' COLLATE 'utf8mb4_bin',
`tenantId` INT(11) NULL DEFAULT NULL COMMENT '租户 id',
`creator` INT(11) NULL DEFAULT NULL COMMENT '创建者 id',
`createDate` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '也是注册时间',
`updateDate` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`) USING BTREE
)
COMMENT='接入的客户端信息表'
COLLATE='utf8mb4_bin'
clientId
和 clientSecret
都是随机字符串生成的,详见下面创建 client_details 部分。
@RestController
@RequestMapping("/oauth")
public class OauthController implements SsoDAO
/**
* 注册需要接入的客户端信息
*
* @param client
* @return
*/
@PostMapping(value = "/clientRegister", produces = MediaType.APPLICATION_JSON_VALUE)
public String clientRegister(@RequestParam ClientDetails client)
if (!StringUtils.hasText(client.getName()))
throw new IllegalArgumentException("客户端的名称和回调地址不能为空");
String clientId = StrUtil.getRandomString(24);// 生成24位随机的 clientId
ClientDetails savedClientDetails = findClientDetailsByClientId(clientId);
// 生成的 clientId 必须是唯一的,尝试十次避免有重复的 clientId
for (int i &#61; 0; i < 10; i&#43;&#43;)
if (savedClientDetails &#61;&#61; null)
break;
else
clientId &#61; StrUtil.getRandomString(24);
savedClientDetails &#61; findClientDetailsByClientId(clientId);
client.setClientId(clientId);
client.setClientSecret(StrUtil.getRandomString(32));
// 保存到数据库
return ClientDetailDAO.create(client) &#61;&#61; null ? BaseController.jsonNoOk() : BaseController.jsonOk();
……
SSO 登录
你以为上面用户登录就完事了&#xff1f;只是完成了三分之一&#xff0c;完整的单点登录还有其余的 66.6666……% ——我们接着看。
为什么要获取授权码&#xff08;Authorization Code&#xff09;&#xff0c;不能直接返回 Token 吗&#xff1f;因水平所限我也不太清楚&#xff0c;好像为了安全性吧&#xff0c;好像 OAuth 有其他模式不用授权码的&#xff1f;我没去管了&#xff0c;反正最主流就是授权码模式。不懂得看官请琢磨上面的流程图&#xff0c;或者先去消化 OAuth 的机制。
获取授权码接口所需的参数参见 SsoController
控制器的方法&#xff0c;源码这里。
&#64;Autowired
private AuthorizationService authService;
/**
* 获取 Authorization Code
*
* &#64;param client_id 客户端 ID
* &#64;param redirect_uri 回调 URL
* &#64;param scope 权限范围
* &#64;param status 用于防止CSRF攻击&#xff08;非必填&#xff09;
* &#64;param req 请求对象
* &#64;return
*/
&#64;RequestMapping(value &#61; "/authorize_code", produces &#61; BaseController.JSON)
public Object authorize(&#64;RequestParam(required &#61; true) String client_id,
// &#64;formatter:off
&#64;RequestParam(required &#61; true) String redirect_uri,
&#64;RequestParam(required &#61; false) String scope,
&#64;RequestParam(required &#61; false) String status,
HttpServletRequest req)
// &#64;formatter:on
LOGGER.info("获取 Authorization Code");
User loginedUser &#61; null;
try
loginedUser &#61; UserUtils.getLoginedUser(req);
catch (Throwable e)
LOGGER.warning(e);
return SsoUtil.oauthError(ErrorCodeEnum.INVALID_CLIENT);
// 生成 Authorization Code
String authorizationCode &#61; authService.createAuthorizationCode(client_id, scope, loginedUser);
String params &#61; "?code&#61;" &#43; authorizationCode;
if (StringUtils.hasText(status))
params &#43;&#61; "&status&#61;" &#43; status;
return new ModelAndView("redirect:" &#43; redirect_uri &#43; params);
据此我们了解几个事实。
UserUtils.getLoginedUser(req);
这句从 Session 返回已登录的用户信息。code
参数&#xff08;就是授权码&#xff09;跳转到 redirect_uri
。就是说该接口不会返回什么 JSON。状态码有时效性&#xff0c;一般十分钟&#xff0c;而且是一次性的&#xff0c;用完了要销毁。
从原理上讲&#xff0c;这也是客户端服务接入 SSO 的第一步&#xff08;当然我们会提供一个封装好的 SDK&#xff0c;对于调用者是屏蔽细节的&#xff09;。用户成功登录后&#xff0c;已在 SSO 中心留存有 COOKIEs 的登录信息&#xff0c;于是其他第三方应用可以访问 SSO 中心获取用户信息&#xff08;当然不是直接获取&#xff0c;而且先要获取授权码&#xff09;。
客户端可以通过授权码获取 AccessToken&#xff0c;然后再根据 AccessToken 获取用户信息&#xff0c;完成本地登录。总之我们提到了两次登录验证&#xff1a;第一次是用户身份验证&#xff08;用户凭用户名和密码可以登录&#xff09;&#xff1b;第二次是客户端认证&#xff08;客户端凭 id 和密钥再结合用户信息&#xff08;授权码&#xff09;去登录&#xff09;&#xff0c;这部分我们下面小结会详细讲。
进入 authService.createAuthorizationCode()
源码我们看看如何生成授权码。
/**
* 根据 clientId、scope 以及当前时间戳生成 AuthorizationCode&#xff08;有效期为10分钟&#xff09;
*
* &#64;param clientId 客户端ID
* &#64;param scope
* &#64;param user 用户信息
* &#64;return
*/
public String createAuthorizationCode(String clientId, String scope, User user)
if (!StringUtils.hasText(scope))
scope &#61; "DEFAULT_SCOPE";
// 1. 拼装待加密字符串&#xff08;clientId &#43; scope &#43; 当前精确到毫秒的时间戳&#xff09;
String str &#61; clientId &#43; scope &#43; String.valueOf(System.currentTimeMillis());
// 2. SHA1加密
String encryptedStr &#61; Digest.getSHA1(str);
int timeout &#61; ExpireEnum.AUTHORIZATION_CODE.getTime() * 60;
// 3.1 保存本次请求的授权范围
ExpireCache.CACHE.put(encryptedStr &#43; ":scope", scope, timeout);
// 3.2 保存本次请求所属的用户信息
ExpireCache.CACHE.put(encryptedStr &#43; ":user", user, timeout);
// 4. 返回Authorization Code
return encryptedStr;
主要是这么几步&#xff1a;1. 拼装待加密字符串&#xff08;clientId &#43; scope &#43; 当前精确到毫秒的时间戳&#xff09;&#xff1b;2. SHA1 加密&#xff1b;3. 保存到缓存&#xff08;不用保存在数据库&#xff09;。
带过期时间的缓存大家想到的是 Redis&#xff0c;但我这里用了 JVM 的缓存&#xff0c;就是自己写的 Map
&#xff0c;无他&#xff0c;懒得部署 Redis 了……
客户端认证的过程就是颁发 AccessToken。我们看看客户端认证的源码定义&#xff0c;需要哪些参数。
/**
* 通过 Authorization Code 获取 Access Token
*
* &#64;param client_id 客户端 id
* &#64;param client_secret 接入的客户端的密钥
* &#64;param code 前面获取的 Authorization Code
* &#64;param grant_type 授权方式
* &#64;param request 请求对象
* &#64;return
*/
&#64;RequestMapping("/authorize")
public String issue(&#64;RequestParam(required &#61; true) String client_id,
// &#64;formatter:off
&#64;RequestParam(required &#61; true) String client_secret,
&#64;RequestParam(required &#61; true) String code,
&#64;RequestParam(required &#61; true) String grant_type,
HttpServletRequest request)
// &#64;formatter:on
LOGGER.info("通过 Authorization Code 获取 Access Token");
// 校验授权方式
if (!GrantTypeEnum.AUTHORIZATION_CODE.getType().equals(grant_type))
return SsoUtil.oauthError(ErrorCodeEnum.UNSUPPORTED_GRANT_TYPE);
ClientDetails savedClientDetails &#61; findClientDetailsByClientId(client_id);
// 校验请求的客户端秘钥和已保存的秘钥是否匹配
if (!(savedClientDetails !&#61; null && savedClientDetails.getClientSecret().equals(client_secret)))
return SsoUtil.oauthError(ErrorCodeEnum.INVALID_CLIENT);
String scope &#61; ExpireCache.CACHE.get(code &#43; ":scope", String.class);
User user &#61; ExpireCache.CACHE.get(code &#43; ":user", User.class);
// 如果能够通过 Authorization Code 获取到对应的用户信息&#xff0c;则说明该 Authorization Code 有效
if (StringUtils.hasText(scope) && user !&#61; null)
// 过期时间
Long expiresIn &#61; LocalDateUtils.dayToSecond(ExpireEnum.ACCESS_TOKEN.getTime());
// 生成 Access Token
String accessTokenStr &#61; authService.createAccessToken(user, savedClientDetails, grant_type, scope, expiresIn);
// 查询已经插入到数据库的 Access Token
AccessToken authAccessToken &#61; AcessTokenDAO.setWhereQuery("accessToken", accessTokenStr).findOne();
// 生成 Refresh Token
String refreshTokenStr &#61; authService.createRefreshToken(user, authAccessToken);
IssueToken token &#61; new IssueToken(); // 返回数据
token.setAccess_token(authAccessToken.getAccessToken());
token.setRefresh_token(refreshTokenStr);
token.setExpires_in(expiresIn);
token.setScope(authAccessToken.getScope());
return JsonHelper.toJson(token);
else
return SsoUtil.oauthError(ErrorCodeEnum.INVALID_GRANT);
据此我们了解几个事实。
至此就完成了登录了&#xff0c;进度……100%。
至于生成 Token 原理大家可以进入 Service 相关代码浏览&#xff0c;大致都是 SHA1 加密之类的&#xff0c;这里不再赘述。
实际设计中有两点“最佳实践”分享给大家。
一般 Token 时效性。
当然&#xff0c;根据你的场景调整。
逻辑大同小异&#xff0c;我们直接贴代码。
/**
* 通过 Refresh Token 刷新 Access Token
*
* &#64;param refresh_token
* &#64;return
*/
&#64;RequestMapping(value &#61; "/refreshToken", produces &#61; MediaType.APPLICATION_JSON_VALUE)
public String refreshToken(&#64;RequestParam(required &#61; true) String refresh_token)
LOGGER.info("通过 Refresh Token 刷新 Access Token");
RefreshToken authRefreshToken &#61; RefreshTokenDAO.setWhereQuery("refreshToken", refresh_token).findOne();
if (authRefreshToken &#61;&#61; null)
return SsoUtil.oauthError(ErrorCodeEnum.INVALID_GRANT);
// 如果 Refresh Token 已经失效&#xff0c;则需要重新生成
if (SsoUtil.checkIfExpire(authRefreshToken))
return SsoUtil.oauthError(ErrorCodeEnum.EXPIRED_TOKEN);
// 获取存储的 Access Token
AccessToken authAccessToken &#61; AcessTokenDAO.findById(authRefreshToken.getTokenId());
// 获取对应的客户端信息
ClientDetails savedClientDetails &#61; ClientDetailDAO.findById(authAccessToken.getClientId());
// 获取对应的用户信息
User user &#61; UserCommonDAO.UserDAO.findById(authAccessToken.getUserId());
// 新的过期时间
Long expiresIn &#61; LocalDateUtils.dayToSecond(ExpireEnum.ACCESS_TOKEN.getTime());
// 生成新的 Access Token
String newAccessTokenStr &#61; authService.createAccessToken(user, savedClientDetails, authAccessToken.getGrantType(), authAccessToken.getScope(), expiresIn);
IssueToken token &#61; new IssueToken(); // 返回数据
token.setAccess_token(newAccessTokenStr);
token.setRefresh_token(refresh_token);
token.setExpires_in(expiresIn);
token.setScope(authAccessToken.getScope());
return JsonHelper.toJson(token);
有些厂家不是这么 RefreshToken 的&#xff0c;它是使用基本认证的方式验证客户端身份&#xff0c;如 Authorization: Basic $Base64.encode(clientId&#43;":"&#43;clientSecret)
。可见它只需要客户端信息&#xff0c;不需要用户信息。
AccesToken 和 RefreshToken 怎么用呢&#xff1f;这就要看我们 SSO Client 如何调用了&#xff0c;——下篇文章再为大家介绍。
SSO 中心没有想象中的难&#xff0c;当然还有其他周边的问题&#xff0c;如安全性的问题&#xff0c;或者用户权限那部分&#xff0c;会越做越复杂的。不管怎么样只要方向路线正确&#xff0c;那么干就是了&#xff01;
推荐参考文章